Skip to content

fix(backend): 헬스체크에서 RabbitMQ 상태가 항상 UNKNOWN 이던 문제 - #183

Merged
i3months merged 1 commit into
devfrom
fix/rabbitmq-health-path
Aug 19, 2026
Merged

fix(backend): 헬스체크에서 RabbitMQ 상태가 항상 UNKNOWN 이던 문제#183
i3months merged 1 commit into
devfrom
fix/rabbitmq-health-path

Conversation

@i3months

Copy link
Copy Markdown
Member

어떻게 찾았나

배포된 dev(https://stack-up.shop)의 /api/system/health 를 실제로 찔러 봤다.

{
  "status": "UNKNOWN",
  "components": {
    "database": { "status": "UP", "details": { "database": "PostgreSQL", ... } },
    "rabbitmq": { "status": "UNKNOWN", "details": {} },
    "s3":       { "status": "UNKNOWN", "details": {} },
    "aiServer": { "status": "UNKNOWN", "details": {} }
  }
}

database 만 UP 이고 rabbitmq 는 details 까지 비어 있었다.

원인

SystemHealthService 는 Actuator 컴포넌트를 이름으로 조회한다:

private static final ComponentSpec DATABASE = new ComponentSpec("database", "db");
private static final ComponentSpec RABBITMQ = new ComponentSpec("rabbitmq", "rabbitmq");  // ← 없는 키

Spring 은 rabbitHealthContributor 빈을 등록하므로 Actuator 키는 rabbit 이다(빈 이름에서 접미사를 뗀 값). RabbitHealthContributorAutoConfiguration 소스로 확인했다.

없는 이름이라 healthEndpoint.healthForPath("rabbitmq") 가 null 을 돌려주고 그대로 UNKNOWN 이 된다 — 예외가 아니라 조용한 무응답이라 눈에 띄지 않았다.

바로 옆의 database"db" 로 올바르게 매핑돼 있다. 응답 키와 Actuator 키를 분리한 ComponentSpec(name, actuatorPath) 구조 자체는 맞았고, rabbit 값만 틀렸다. 공개 응답 키는 rabbitmq 그대로 유지된다.

영향

/api/system/ready 는 database·rabbitmq 를 종합하는데, rabbitmq 가 영구 UNKNOWN 이라 readiness 가 항상 UNKNOWN 이었다. readiness 판단에 쓸 수 없는 상태다.

RabbitMQ 가 죽으면 질문 생성·꼬리질문·피드백·음성 분석이 전부 멎는다. 제품이 사실상 죽는데 이 엔드포인트는 아무것도 알려주지 않았다.

컨테이너 healthcheck 는 Spring 자체 /actuator/health(Actuator 종합)를 쓰므로 배포 게이트 자체는 영향이 없었다 — RabbitMQ 장애 시 배포는 정상적으로 실패한다. 문제는 운영 중 모니터링용 공개 API 쪽이다.

테스트가 왜 못 잡았나

픽스처를 "rabbitmq" 키로 레지스트리에 등록해서 같은 실수를 그대로 재현하고 있었다. 프로덕션에서 Spring 이 "rabbit" 으로 등록한다는 사실이 테스트에 반영된 적이 없다.

  • 기존 두 테스트의 픽스처를 실제 Actuator 키(rabbit)로 교정
  • health_readsRabbitFromActuatorKeyNotResponseKey — 응답 키는 rabbitmq 로 유지하면서 Actuator 는 rabbit 에서 읽는지, details 까지 전달되는지
  • health_reportsUnknownWhenActuatorHasNoSuchComponent — 없는 이름일 때의 동작을 분리해 고정

남는 것 (이 PR 범위 밖)

s3 · aiServer 는 커스텀 indicator 가 아직 없어 UNKNOWN 이고, 그래서 /health종합 status 는 여전히 UNKNOWN 으로 고정된다(기존 테스트가 이 상태를 의도로 명시하고 있다). 이 PR 로 /ready(database·rabbitmq)는 정상 동작하게 된다.

두 indicator 를 실제로 구현하려면 S3 headBucket, AI 서버 GET /health 호출이 필요하다 — 별도 작업으로 두는 게 맞다고 봤다. docs/observability.md 에 현재 상태를 적어뒀다.

영향 범위

  • DB 마이그레이션: 없음
  • API contract 변경: 없음 (응답 키 rabbitmq 유지, 값만 실제 상태로)
  • 환경변수: 없음

리뷰어 체크포인트

  • /health 종합 status 를 쓸 수 있게 하려면 s3·aiServer indicator 구현이 필요하다. 우선순위 판단 부탁

배포된 dev 의 `/api/system/health` 를 실제로 찔러 보다 발견했다. database 만 UP 이고
rabbitmq 는 details 까지 빈 채 UNKNOWN 이었다.

`SystemHealthService` 는 Actuator 컴포넌트를 이름으로 조회하는데, RabbitMQ 를 `"rabbitmq"`
로 찾고 있었다. Spring 은 `rabbitHealthContributor` 빈을 등록하므로 실제 키는 **`rabbit`**
이다(빈 이름에서 접미사를 뗀 값). 없는 이름이라 `healthForPath` 가 null 을 돌려주고
그대로 UNKNOWN 이 된다 — 예외가 아니라 조용한 무응답이라 눈에 띄지 않았다.

바로 옆의 `database` 는 `"db"` 로 올바르게 매핑돼 있었다. 응답 키와 Actuator 키를 분리한
`ComponentSpec(name, actuatorPath)` 구조는 이미 맞았고, rabbit 값만 틀렸다.

영향: `/api/system/ready`(database·rabbitmq 종합)가 **항상 UNKNOWN** 이라 readiness 판단에
쓸 수 없었다. RabbitMQ 가 죽으면 질문 생성·피드백·음성 분석이 전부 멎는데 이 엔드포인트는
아무것도 알려주지 않는다. (컨테이너 healthcheck 는 Spring 자체 `/actuator/health` 를 쓰므로
배포 게이트 자체는 영향 없었다.)

## 테스트가 왜 못 잡았나

픽스처를 `"rabbitmq"` 키로 등록해서 **같은 실수를 그대로 재현**하고 있었다. 실제 Actuator
키로 바꾸고, 매핑 자체를 못 박는 테스트 2개를 추가했다 — 응답 키는 rabbitmq 로 유지하면서
Actuator 는 rabbit 에서 읽는지, 그리고 없는 이름일 때 UNKNOWN 이 되는지.

`docs/observability.md` 에 키가 다르다는 점과 s3·aiServer 미구현으로 `/health` 종합 status 가
UNKNOWN 으로 고정된다는 점을 적었다.
@i3months
i3months merged commit 3627e04 into dev Aug 19, 2026
5 checks passed
@i3months
i3months deleted the fix/rabbitmq-health-path branch August 19, 2026 06:32
i3months added a commit to i3months/stackup that referenced this pull request Aug 28, 2026
…ness 로

Team-StackUp#183 에서 남겨둔 부분. `/api/system/health` 의 종합 status 가 s3·aiServer 미구현 때문에
UNKNOWN 으로 고정돼 있어 엔드포인트를 쓸 수 없었다.

## indicator 두 개

- `S3HealthIndicator` — `headBucket` 으로 엔드포인트·자격증명·버킷을 한 번에 확인.
  스토리지가 죽으면 이력서 업로드·음성 답변·TTS 재생이 전부 실패한다.
  `ObjectStorageClient.verifyAvailable()` 을 추가했다(get/put 은 대상 키가 필요해서 헬스체크에
  쓸 수 없다).
- `AiServerHealthIndicator` — **작업 큐의 컨슈머 수**로 판단한다. Core 는 AI 를 HTTP 로
  호출하지 않으므로(아키텍처 §4.1: RabbitMQ 전용) 헬스체크 하나 때문에 Core→AI HTTP 의존을
  새로 만들지 않았다. 컨슈머 수가 오히려 정확한 신호다 — 프로세스 생존보다 **큐를 실제로
  구독 중인가**가 중요하고, 컨슈머 0 이면 질문 생성·꼬리질문·피드백이 쌓이기만 한다.
  브로커 자체가 죽은 경우는 UNKNOWN 으로 둔다(그건 rabbitmq 컴포넌트가 알려주므로 DOWN 을
  겹쳐 내면 "AI 가 죽었다" 로 오독된다).

빈 이름이 곧 Actuator 키가 되도록 맞췄다(`s3HealthIndicator`→`s3`) — Team-StackUp#183 과 같은 함정.

## 컨테이너 healthcheck 를 종합 → readiness 로

indicator 를 추가하면 종합(`/actuator/health`)에 s3·aiServer 가 들어가는데, 컨테이너
healthcheck 가 그 종합을 보고 있었다. 그대로 두면 **AI 가 죽었다고 백엔드 컨테이너가
unhealthy 가 되어** 정작 멀쩡한 로그인·히스토리까지 rotation 에서 빠진다.

healthcheck 를 `/actuator/health/readiness` 로 바꾸고 readiness 그룹을
`readinessState + db + rabbit` 으로 **명시**했다. 그냥 liveness 로 바꾸면 DB 장애도 배포
게이트를 통과해 버리므로, 백엔드가 자기 일을 하려면 반드시 필요한 것만 남겼다.

그룹 멤버십 검증은 기본값(엄격)을 유지한다 — 이름을 틀리면 부팅이 실패해 배포에서 잡힌다.
조용히 UNKNOWN 이 되는 것보다 낫다(Team-StackUp#183 이 정확히 그 실패였다). 테스트 프로파일은
DataSource 를 제외하므로 거기서만 그룹을 축소했다.

## 테스트

- `AiServerHealthIndicatorTest` (4) — 컨슈머 있음 UP, 0이면 DOWN(쌓인 메시지 수 포함),
  큐 없음 DOWN, 브로커 불가 UNKNOWN
- `S3HealthIndicatorTest` (2) — 도달 가능 UP, 실패 시 DOWN + 사유
i3months added a commit to i3months/stackup that referenced this pull request Aug 28, 2026
마지막 점검에서 나왔다. `/api/system/*` 는 permitAll 인데 컴포넌트 상세를 그대로 담고 있었다:

    "rabbitmq": { "version": "4.3.5" }
    "s3":       { "bucket": "stackup" }
    "aiServer": { "queue": "ai.generate.questions", "consumers": 1, "pendingMessages": 0 }

Actuator 는 기본값이 `show-details: never` 다. 그런데 `SystemHealthService` 가 descriptor 에서
상세를 직접 꺼내 자기 응답에 담으면서 **그 보호를 우회**하고 있었다. RabbitMQ 버전은 알려진
CVE 를 겨냥하는 데 쓰이고, 버킷명·큐 이름·적체량은 내부 토폴로지와 부하를 그대로 드러낸다.

`rabbitmq` 상세는 Team-StackUp#183(키 오타 수정)으로, `s3`·`aiServer` 상세는 Team-StackUp#184(indicator 구현)로
오늘 내가 늘린 것이다 — 늘린 김에 닫는다.

`ComponentHealthResponse` 에서 `details` 를 제거해 **이름·상태만** 담는다. 프로브 용도에는
그것으로 충분하고, 상세가 필요하면 호스트에서 Spring 자체 `/actuator/health` 를 본다
(nginx 가 외부로 라우팅하지 않는 것을 확인했다 — 공개 URL 로는 SPA HTML 이 돌아온다).
반사(reflection)로 상세를 꺼내던 `extractDetails` 도 함께 사라진다.

프론트·realtime 어디서도 이 엔드포인트를 호출하지 않아 소비자 영향은 없다.

테스트: `health_doesNotExposeComponentDetails` — 상태는 전달되지만 응답 타입에 상세 필드가
아예 없다는 것을 record 컴포넌트로 못 박는다.

## 함께: frontend/.env.example 의 잘못된 호스트

`VITE_API_BASE_URL`·`VITE_SSE_BASE_URL` 가 `https://www.udangtang.site` 를 가리키고 있었다.
실제 배포 호스트는 `https://stack-up.shop` 다(deploy-app.yml 이 프론트 빌드에 주입하는 값).
새로 온 사람이 그대로 복사하면 존재하지 않는 백엔드를 보게 된다.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant